昨天提到了 module:一種比 class 涵蓋更多功能的 boundary,通常代表整個 application 中一個完整的功能。而一個 application,就是由許多這樣的 module 組成的。
以開源的 coding agent pi 為例,它主要由四個 module 組成:
pi-ai: 統一呼叫各家 LLM providerpi-agent-core: 呼叫工具並管理 agent 的 statepi-tui: 把結果顯示在 terminalpi-coding-agent: 組合以上三者,成為使用者操作的 CLI這些 module 並不是各自獨立的。pi-coding-agent 要用到其他三個 module 才能運作,pi-agent-core 則要透過 pi-ai 才能呼叫並且使用 model。
由此可見,module 與 module 之間是存在依賴關係的。
如果今天把每一個 dependency 都畫成一條箭頭,整個系統就會連成一張有向圖,稱為 dependency graph。
而透過這張有向圖,我們可以理解一個 Application,做出一個 改動 需要理解多少、會影響多遠,甚至還能判斷目前架構設計的合理性,以及 module 的 boundary 是否還守得住。這就是今天的主題。
Module 之間的 dependency,在 code 裡就是 import:A 要使用 B,就得 import B 公開的名稱。所以畫法很簡單:把每個 module 當成一個點,A import 了 B,就從 A 畫一條箭頭指向 B,表示 A 依賴 B。
把 pi 的四個 module,連同它們周邊的四個 module 畫出來,是這樣:

畫這張圖不必讀任何實作,只要看每個 module import 了誰。它卻能回答開頭的兩個問題:一次 change 需要理解多少、會影響多遠。這兩個問題,正好對應箭頭的兩個方向。
箭頭從 A 指向 B,表示 A 的 code 用到了 B。所以修改 A 的時候,B 是我們必須理解的對象。
假設我們要修改 pi-agent-core,例如調整呼叫工具的流程。順著它的箭頭看出去,只指向 pi-ai,這就是這次修改需要理解的全部;pi-tui、pi-mcp、pi-codemode 都不在箭頭上,不必讀。
而且需要理解的,只到對方的門口為止。昨天說過,module 對外只留一扇門,也就是它公開的名稱與說明。pi-agent-core 需要知道 pi-ai 公開了哪些名稱;至於每一家 provider 的 API 如何串接、pi-ai 自己又依賴了誰,都是門後的事。
順著箭頭,是完成一次修改需要理解的範圍;每一條箭頭,都只需要讀到對方的那扇門。
理解是順著箭頭走的,change 的傳播則相反:B 改變了,指向 B 的 A 才可能需要跟著改。
假設 pi-tui 的 contract 改了。逆著箭頭往回看,指向它的只有 pi-coding-agent;其餘的 module 都不在這條路上,完全不受影響。
pi-ai 則不同。pi-agent-core、pi-coding-agent 與 pi-durable 都指向它,它的 contract 一改,這三個 module 都可能要跟著改。
這裡說的,是 contract 的改變。門後的修改,例如 pi-ai 改寫某一家 provider 的串接方式,根本不會沿著箭頭傳出去;這正是每個 module 只留一扇門的意義。
逆著箭頭,是一次 contract 的修改可能影響的範圍;被越多箭頭指著,影響就越廣。
回頭看這張圖,沒有任何一條箭頭繞回來,這樣的圖稱為 DAG(directed acyclic graph,有向無環圖)。正因為沒有繞回來,兩個範圍才有盡頭。
但箭頭要繞回來,並不困難。假設我們請 agent 加一個功能:讓 pi-ai 收到 model 的 tool call 時,直接把工具執行完。最省事的做法,是在 pi-ai 裡 import pi-agent-core 現成的工具呼叫邏輯。
只多了一行 import,卻多了一條從 pi-ai 指向 pi-agent-core 的箭頭。而 pi-agent-core 本來就指向 pi-ai,兩條箭頭接起來,就形成了一個環,也就是 cycle(循環依賴)。
pi-agent-core -> pi-ai -> pi-agent-core -> pi-ai
當今天 dependency graph 出現環時,情況就有點複雜了。在依賴較少的時候,問題可能並不明顯,甚至還提升了開發速度,畢竟很容易達成目標。但時間一長,先不管編譯所產生的問題,人與 coding agent 都會漸漸地無法分清楚兩個 module 之間在依賴關係下的地位,而導致錯亂。
這就好比大公司中,下層部門能使喚上層部門一樣。
這也使得整個環上的理解範圍與影響範圍合而為一。想理解 pi-ai,得先理解 pi-agent-core,而後者又建立在 pi-ai 之上,兩者再也無法單獨理解;任何一邊的 contract 改了,另一邊都可能要跟著改。
換句話說,兩個 module 雖然各有一扇門,門後卻是同一個房間。
環上的 module,理解範圍與影響範圍都會合而為一;boundary 還在,卻已經擋不住 change。
先思考兩個極端的例子:
第二種做法看似解決了問題:只要把 pi-ai 與 pi-agent-core 合併成一個 module,箭頭自然就不會繞回來。但這兩個極端其實大同小異,都喪失了 boundary 的優勢。前者兩扇門都還在,門後卻是同一個房間;後者則是連門都拆了,直接併成一個房間。不管是哪一種,想改其中一邊,都得把兩邊一起理解、一起檢查。
從圖的形狀來看,前者是箭頭繞成了圈,後者是好幾個點縮成了一個點。那麼,一張好的 dependency graph 應該長什麼樣子?
pi-coding-agent 在最上層,pi-agent-core 在中間,pi-ai 在最下層。pi-ai 就是這樣的點;而被越多箭頭指著,它的 contract 就越要穩定。整張圖看起來,就像一條由上往下流、不會倒流的河。
它背後的理念是:每個 module 守住自己的 boundary,依賴的箭頭有方向,但不回頭。
這對人與 coding agent 都是一樣的:這張圖是一份不必讀實作的地圖。接到任務時,順著箭頭就知道要讀哪幾扇門;改了某個 contract,逆著箭頭就知道要檢查哪些 module 與它們的 test。
至於環,也不必靠肉眼去找。有些語言直接不允許:Go 的 compiler 遇到循環 import 就拒絕編譯。Python 則不會阻止,有時要到執行時才出錯,所以需要工具把關:pydeps 能把 dependency graph 畫出來並標出環,Import Linter 能在 CI 中檢查每一層的 import 方向。JavaScript 與 TypeScript 也有 madge、dependency-cruiser 這類工具。
所以當 agent 寫出一條往回指的箭頭,工具會指出環在哪裡、違反了哪一層。agent 能據此調整依賴的方向,而不是把錯誤藏起來了事。